Generate synthetic content
You need to fetch personalized data from external APIs and serve it to the user without a full web stack.
While the view layer of the web consists of HTML, CSS, and JavaScript, much of the data you want to present to your users is better represented and exchanged using formats like JSON. Generally, data is templated into a view within the core infrastructure of an application and then the HTML can be cached at the edge. In this tutorial you can learn how to move the templating process to the edge, allowing your core application to focus on pure data.
This tutorial documents the creation of a fast, personalized weather dashboard powered by Fastly's geolocation API and OpenWeatherMap. The OpenWeatherMap API is maintained by OpenWeather, a team of data scientists offering a free plan to build your own tools on top of their data. Later in the guide, you will create an account and get your own API token to use for the example.
You can see below exactly what you will be building:
OpenWeatherMap API
The OpenWeatherMap API provides an endpoint that you can send a request to with latitude and longitude co-ordinates of any place in the world, and receive back JSON-encoded weather information for that location. This is ideal for a weather app, and can be combined with the Fastly Geolocation API to fetch weather information for the location associated with the end user's IP address.
You will integrate with this later, but for now let's look at an example request and response from the OpenWeatherMap API:
Example Response
{ "lat":51.521, "lon":-0.136, "timezone":"Europe/London", "timezone_offset":0, "current":{ "dt":1612189870, "sunrise":1612165128, "sunset":1612198163, "temp":4.21, "feels_like":0.78, "pressure":999, "humidity":87, "dew_point":2.24, "uvi":0.26, "clouds":90, "visibility":10000, "wind_speed":2.57, "wind_deg":10, "weather":[ { "id":804, "main":"Clouds", "description":"overcast clouds", "icon":"04d" } ] }, ...}
Instructions
IMPORTANT: This tutorial assumes that you have already have the Fastly CLI installed. If you are new to the platform, read our Getting Started guide.
Initialize a project
If you haven't already created a Rust-based Compute@Edge project, run fastly compute init in a new directory in your terminal and follow the prompts to provision a new service using the default Rust starter kit:
$ mkdir weather && cd weather$ fastly compute init
Creating a new Compute@Edge project.
Press ^C at any time to quit.
Name: [weather]Description: A weather dashboard served at the edgeAuthor: My NameLanguage:[1] Rust[2] JavaScript[3] AssemblyScript (beta)[4] Other ('bring your own' Wasm binary)Choose option: [1]Starter kit:[1] Default starter for Rust A basic starter kit that demonstrates routing, simple synthetic responses and overriding caching rules. https://github.com/fastly/compute-starter-kit-rust-default[2] Beacon termination Capture beacon data from the browser, divert beacon request payloads to a log endpoint, and avoid putting load on your own infrastructure. https://github.com/fastly/compute-starter-kit-rust-beacon-termination[3] Static content Apply performance, security and usability upgrades to static bucket services such as Google Cloud Storage or AWS S3. https://github.com/fastly/compute-starter-kit-rust-static-contentChoose option or paste git URL: [1]
Install dependencies
When the JSON responses come back from OpenWeatherMap, you'll need something to parse those so that you can use the values in your template. serde
is a Rust crate that provides serialization and deserialization for common data formats like JSON. To parse the response data from the API, you can use the serde_json
crate.
To generate the view that will be sent to the client, you will need a templating library. For the purposes of this tutorial we suggest tinytemplate
. The chrono
crate is also useful to provide functions for parsing and formatting dates and times.
Add these dependencies to your Cargo.toml file:
serde = { version = "^1", features = ["derive"] }serde_json = "^1"tinytemplate = "1.1"chrono = "0.4.19"
You will also need some helper methods specific to this tutorial, including manipulation of weather data and icon matching. We have packaged these into a Rust crate that you can include in your project:
weather_helpers = "0.1.2"
Use the OpenWeatherMap backend
Obtain an API token
To use the free OpenWeatherMap API, you will need an account and API key from them. Head to the sign up page and enter your details. Follow their instructions to activate your account so you can generate an API token.
Head to the API keys dashboard and create a new key for this project. Or, just copy the default that has been generated for you. You will need to add this key as a property named key
to an edge dictionary named weather_auth
on your Fastly service, which you will use in the program next to securely fetch the API token. You can do this however you prefer:
- Via the web interface
- Via the API
- Via the CLI, using the fastly dictionary create and fastly dictionary-item create commands
Fetch the API key from an edge dictionary
Remove the contents of the main
method generated by the default starter kit, and write a method to retrieve the API key you added to the edge dictionary earlier:
use fastly::Dictionary;fn get_api_key() -> String { match Dictionary::open("weather_auth").get("key") { Some(key) => key, None => panic!("No OpenWeatherMap API key!"), }}
You will use this method later to generate an API request to OpenWeatherMap.
Match request route
This tutorial will serve the templated HTML, stylesheets, and images from the same Compute@Edge service. To achieve this, you can use Rust's pattern syntax to run different logic based on properties of the incoming request, such as the path. Add this code to your now-empty main function:
// We provide helpful constants for HTTP methods, headers, status codes, and more:use fastly::http::{header, Method, StatusCode};/// The entry point for your application.#[fastly::main]fn main(req: Request) -> Result<Response, Error> { // Return early if the request method is not GET. if req.get_method() != Method::GET { return Ok(Response::from_status(StatusCode::METHOD_NOT_ALLOWED) .with_body("This method is not allowed")); }
let resp = match req.get_path() { // Serve weather widget "/" => { } // Serve background image "/bg-image.jpg" => { } // Serve static assets "/style.css" => { } // Catch all other requests and return a 404. _ => Response::from_body("The page you requested could not be found") .with_status(StatusCode::NOT_FOUND), };
Ok(resp)}
Fetch weather for the user's location
Using Fastly's geolocation API to fetch the user's latitude and longitude will give you all the data needed to construct a request to OpenWeatherMap. Add the imports and backend constant as shown below, some structs to define the shape of the data that comes back from OpenWeatherMap, and the logic within the "/"
branch of the match clause to make a request to the backend you defined earlier:
use chrono::{Date, Datelike, Local};use fastly::geo::{geo_lookup, Geo};// Define a constant for the backend name, as shown in your Fastly service:const BACKEND_NAME: &str = "openweathermap_api"; // Serve weather widget "/" => { // Get the end user's location let location = geo_lookup(req.get_client_ip_addr().unwrap()).unwrap(); // Get the local time let local = Local::now().date();
// Log output helps you debug issues when developing your service. // Run `fastly log-tail` to see this output live as you make requests. println!( "Requesting weather for {}, {} ({}, {})", location.latitude(), location.longitude(), location.city(), location.country_name() ); // Build the API request, and set the cache override to PASS let url = format!( "http://api.openweathermap.org/data/2.5/onecall?lat={}&lon={}&appid={}&units={}", location.latitude(), location.longitude(), get_api_key(), units ); let bereq = Request::new(Method::GET, url).with_pass(true);
// Send the request to the backend let mut beresp = bereq.send(BACKEND_NAME)?;
// Get the response body into an APIResponse let api_response = beresp.take_body_json::<APIResponse>()?;
let body_response = generate_view(api_response, location, local, &units);
Response::from_body(body_response) .with_status(StatusCode::OK) .with_content_type(fastly::mime::TEXT_HTML_UTF_8) }/// Struct representing API response#[derive(Deserialize)]struct APIResponse { current: CurrentReport, daily: Vec<DailyReport>, minutely: Vec<MinutelyReport>,}
/// Struct representing a single response entry#[derive(Deserialize)]struct CurrentReport { temp: f32, wind_speed: f32, humidity: f32, weather: Vec<WeatherReport>,}
/// Struct representing a single day's weather#[derive(Deserialize)]struct DailyReport { dt: i32, temp: Temperatures, weather: Vec<WeatherReport>,}
/// Struct representing a single weather report#[derive(Deserialize)]struct WeatherReport { description: String, icon: String,}
/// Struct representing precipitation data#[derive(Deserialize)]struct MinutelyReport { precipitation: f32,}
/// Struct representing a set of temperatures#[derive(Deserialize)]struct Temperatures { day: f32,}
/// Basic struct with minimal info about the next days#[derive(Serialize)]struct NextDay { day: String, temp: String, icon: String,}fn generate_view( api_response: APIResponse, location: Geo, local: Date<Local>, units: &str,) -> String {}
At this point, visiting your application's domain will fetch your weather info, but will not yet return it to your browser. You'll fix this in the next step.
Generate HTML using a template
This tutorial uses the TinyTemplate crate to interpolate dynamic weather data into a predefined static template. If you'd like to use the template and assets from this example, download static.zip and extract it in the src
folder of your project. Otherwise, feel free to build your own frontend using the variables defined in the TemplateContext
struct.
Now you need to define a type for the data you will pass to the template, and fill in the empty generate_view
method that you added earlier. This will take in the APIResponse
parsed from the API response in the last step to generate the HTML.
use tinytemplate::TinyTemplate;use weather_helpers;/// Context for TinyTemplate#[derive(Serialize)]struct TemplateContext { day: String, day_short: String, date: String, city: String, temp: String, rain: String, wind: String, humidity: String, description: String, icon: String, next_days: Vec<NextDay>, is_metric: bool,}fn generate_view( api_response: APIResponse, location: Geo, local: Date<Local>, units: &str,) -> String { // Initialize template let mut tt = TinyTemplate::new(); tt.add_template("weather", include_str!("static/index.html")) .unwrap();
// Get the data for the next three days and put them in a vector to iterate them later in // the template let mut next_days: Vec<NextDay> = Vec::new(); for i in 0..3 { next_days.push(NextDay { day: weather_helpers::datetime_to_day(format!("{}", api_response.daily[i + 1].dt)), temp: (api_response.daily[i + 1].temp.day as i32).to_string(), icon: weather_helpers::get_feather_weather_icon( &api_response.daily[i + 1].weather[0].icon, ), }); }
// Fill the template context let context = TemplateContext { day: weather_helpers::weekday_full(local.weekday().to_string()), day_short: local.weekday().to_string(), date: local.format("%e %B %Y").to_string(), city: String::from(location.city()), temp: (api_response.current.temp as i32).to_string(), rain: format!("{}", api_response.minutely[0].precipitation), wind: format!("{}", api_response.current.wind_speed), humidity: format!("{}", api_response.current.humidity), description: format!("{}", api_response.current.weather[0].description).replace("\"", ""), icon: weather_helpers::get_feather_weather_icon(&api_response.current.weather[0].icon), next_days, is_metric: units == "metric", };
tt.render("weather", &context).unwrap()}
Serve static assets
The dashboard should now load and show your current weather! It doesn't look that good though, so set up some routes to serve the CSS file, icon script, and one of the background images from the asset bundle.
Passing in the user's geographical location to the get_season
helper will allow you to serve a dynamic background image based on the current season:
use weather_helpers::Season; // Serve background image "/bg-image.jpg" => { // Serve dynamic background image based on season let location = geo_lookup(req.get_client_ip_addr().unwrap()).unwrap(); let local = Local::now().date(); let image: &[u8] = match weather_helpers::get_season(location, local) { Season::Summer => include_bytes!("static/img/summer.jpg"), Season::Autumn => include_bytes!("static/img/autumn.jpg"), Season::Winter => include_bytes!("static/img/winter.jpg"), Season::Spring => include_bytes!("static/img/spring.jpg"), };
Response::from_body(image) .with_status(StatusCode::OK) .with_content_type(fastly::mime::IMAGE_JPEG) } // Serve static assets "/style.css" => { Response::from_body(include_str!("static/style.css")) .with_content_type(fastly::mime::TEXT_CSS) } "/feather.min.js" => Response::from_body(include_str!("static/feather.min.js")) .with_content_type(fastly::mime::TEXT_JAVASCRIPT),
Switch units per-user
Bearing in mind that your application will be deployed globally, it is a good idea to localise your views to make them accessible for as many users as possible. For temperatures, you can read a query string parameter to determine the desired units to use:
#[derive(Deserialize)]struct QueryParams { units: Option<String>,} // Fetch the query string and parse it into the `QueryParams` type let query: QueryParams = req.get_query()?;
// Get units from query params, or default to "metric" let units = match query.units { Some(units) => units, None => String::from("metric"), };
The "Switch Units" link in the example template should now work as intended.
Build and deploy
Congratulations! You now have an elegant weather dashboard that can run at the edge. If you haven't yet, run the following command to build and deploy your service to the Fastly network. When prompted, enter api.openweathermap.org
as the backend hostname:
$ fastly compute publish✓ Initializing...✓ Verifying package manifest...✓ Verifying local rust toolchain...✓ Building package using rust toolchain...✓ Creating package archive...
SUCCESS: Built rust package weather (pkg/weather.tar.gz)
There is no Fastly service associated with this package. To connect to an existing serviceadd the Service ID to the fastly.toml file, otherwise follow the prompts to create aservice now.
Press ^C at any time to quit.
Create new service: [y/N] y
✓ Initializing...✓ Creating service...
Domain: [random-funky-words.edgecompute.app]
Backend (hostname or IP address, or leave blank to stop adding backends): api.openweathermap.orgBackend port number: [80] 443Backend name: [backend_1] openweathermap_api
Backend (hostname or IP address, or leave blank to stop adding backends):
✓ Initializing...✓ Creating domain 'random-funky-words.edgecompute.app'...✓ Creating backend 'openweathermap_api' (host: api.openweathermap.org, port: 443)...✓ Uploading package...✓ Activating version...
Manage this service at: https://manage.fastly.com/configure/services/PS1Z4isxPaoZGVKVdv0eY
View this service at: https://random-funky-words.edgecompute.app
SUCCESS: Deployed package (service PS1Z4isxPaoZGVKVdv0eY, version 1)
Next Steps
Now that you understand how to make your program act on geolocation data, why not use this to select an appropriate default unit based on the user's locale?
You could also try improving the logging provided in the tutorial, using the log-fastly
crate. This would allow you to log data to any of Fastly's supported logging backends.