In this post we will learn how to build a simple API to count profile views on GitHub using Rust, Actix and MongoDB. You may consider it a tutorial of how to start a typical HTTP API project from scratch.
The idea of collecting view counts is simple. We need to embed a tracking image into a github page which users can view and display the collected stats. To display the collected stats we will use the shields project
which allows to create badges with custom data providers.
Prerequisites
First of all you need to set up proper tools to start developing. Here's the minimal list:
- Rust Language - the Rust SDK to build and run applications
- VSCode - an editor to modify your application source code
- Rust Analyzer - a VSCode extension that provides a better experience for development with Rust
- Docker Desktop - to build or use Docker images
Docker is also useful for developing your applications locally in case you need a database instance without installing one in your operating system.
Setup project
Let's create a Rust project with this cargo command:
cargo new counter
Now you can open the project folder in VSCode
and add dependencies that are needed for developing an HTTP API. In this project the dependencies will be:
- Actix - a popular Rust web framework
- MongoDB - the Rust driver for MongoDB
- Serde - a Rust library for JSON serialization/deserialization
You can do it either editing Cargo.toml
file or by this command in the project directory:
cargo add actix-web mongodb serde
You also need to modify the serde
feature flags to allow using of the derive
macro for more declarative serialization.
So the dependencies section will look like this:
[dependencies]
actix-web = "4.1.0"
mongodb = "2.3.0"
serde = { version = "1.0.140", features = ["derive"] }
Database
Before we start to code we need a MongoDB
instance to store out application data. We will make it by using the official Docker image from DockerHub Registry.
Let's create a Docker container for the local development:
docker run -d --name local-mongo \
-p 27017:27017 \
-e MONGO_INITDB_ROOT_USERNAME=admin \
-e MONGO_INITDB_ROOT_PASSWORD=pass \
mongo:latest
Once it's done you can connect to your local MongoDB with this connection string mongodb://admin:pass@localhost:27017
.
To allow the application to access the database you have to add the connection string to the config.toml
file in the .cargo
directory of the project:
[env]
DATABASE_URL = "mongodb://admin:pass@localhost:27017"
You may also need this command to (re)start the MongoDB container:
docker restart local-mongo
Project structure
In this project we will have two layers to organize the code. The first layer will represent a data service that we will use to work with a MongoDB collection to add and count GitHub profile views. The second layer will expose API endpoints for tracking views and retrieving collected stats.
Let's make the data service with those two functions described above:
// service.rs
// This structure represents a view event of a GitHub or Web page.
#[derive(Serialize, Deserialize)]
pub struct View {
// The unique tracker name of this view event.
name: String,
// The date time of this view event.
date: DateTime,
}
// The data service to add views and collect stats.
#[derive(Clone)]
pub struct ViewService {
collection: Collection<View>,
}
impl ViewService {
// Create an instance of the ViewService struct from a given db collection.
pub fn new(collection: Collection<View>) -> Self {
Self { collection }
}
// Register a view event in the storage.
pub async fn add_view<T: AsRef<str>>(&self, name: T) -> Result<InsertOneResult> {
let view = View {
name: name.as_ref().to_owned(),
date: DateTime::now(),
};
self.collection.insert_one(view, None).await
}
// Collect the view counts for a give unique tracker name.
pub async fn get_view_count<T: AsRef<str>>(&self, name: T) -> Result<u64> {
self.collection
.count_documents(doc! {"name": name.as_ref()}, None)
.await
}
}
And the endpoint layer for our API:
// endpoint.rs
// The `shields.io` custom endpoint contract.
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Shield {
schema_version: i32,
label: String,
message: String,
cache_seconds: Option<u32>,
}
impl Shield {
pub fn new(label: String, message: String) -> Self {
Self {
schema_version: 1,
label,
message,
cache_seconds: Some(300),
}
}
}
#[derive(Deserialize)]
pub struct ViewParams {
name: String,
label: Option<String>,
}
#[derive(Deserialize)]
pub struct AddViewParams {
name: String,
}
// Count the view events for a given unique tracker name.
// The response is compatible with the `shields.io` project.
// GET /views?name=NAME&label=LABEL
#[get("/views")]
pub async fn get_view_count(app_state: Data<AppState>, query: Query<ViewParams>) -> impl Responder {
let ViewParams { name, label } = query.into_inner();
if name.is_empty() {
return HttpResponse::BadRequest().finish();
}
match app_state.view_service.get_view_count(name.as_str()).await {
Ok(count) => {
let label = label.unwrap_or_else(|| String::from("Views"));
let message = count.to_string();
let shield = Shield::new(label, message);
HttpResponse::Ok().json(shield)
}
Err(_) => HttpResponse::InternalServerError().finish(),
}
}
// Render a tracking pixel and register a view in the storage.
// GET /tracker?name=NAME
#[get("/tracker")]
pub async fn add_view(app_state: Data<AppState>, query: Query<AddViewParams>) -> impl Responder {
// Statically load the svg pixel image from the external file during compilation.
// No need for escaping and other funny stuff.
const PIXEL: &str = include_str!("pixel.svg");
const SVG_MIME: &str = "image/svg+xml";
// Disable caching to prevent GitHub or any other proxy to cache the rendered image.
const CACHE_CONTROL: (&str, &str) = (
"Cache-Control",
"max-age=0, no-cache, no-store, must-revalidate",
);
let AddViewParams { name } = query.into_inner();
if name.is_empty() {
return HttpResponse::BadRequest().finish();
}
let _ = app_state.view_service.add_view(name).await;
HttpResponse::Ok()
.append_header(CACHE_CONTROL)
.content_type(SVG_MIME)
.body(PIXEL)
}
Let's wrap it up together:
// main.rs
// The settings to run an application instance.
struct Settings {
/// The web server port to run on.
port: u16,
/// The MongoDB database url.
database_url: String,
}
impl Settings {
// Create an instance of the Settings struct
// from the environment variables.
pub fn from_env() -> Self {
let port: u16 = env::var("PORT")
.expect("PORT expected")
.parse()
.expect("PORT must be a number");
let database_url = env::var("DATABASE_URL").expect("DATABASE_URL expected");
Self { port, database_url }
}
}
// The shareable state for accessing
// across different parts of the application.
pub struct AppState {
view_service: ViewService,
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
// Read the application settings from the env.
let Settings { port, database_url } = Settings::from_env();
// Create the database connection for the application.
let options = ClientOptions::parse(database_url).await.unwrap();
let client = Client::with_options(options).unwrap();
let db = client.database("counter_db");
let view_collection = db.collection::<View>("views");
// Initialize and start the web server.
HttpServer::new(move || {
// Create the shareable state for the application.
let view_service = ViewService::new(view_collection.clone());
let app_state = AppState { view_service };
// Create the application with the shareable state and
// wired-up API endpoints.
App::new()
.app_data(Data::new(app_state))
.service(endpoint::get_view_count)
.service(endpoint::add_view)
})
.bind(("0.0.0.0", port))?
.run()
.await
}
To run a web server just build and start the application:
cargo run
Conclusion
So now you can call the API endpoints and create your own badges to display views stats.
Materials:
- Example how to collect and display the views of a GitHub profile.
- Complete source code can be found here.
Hope you find this tutorial helpful.
Top comments (0)