Building a CRUD API with Atmo, Rust and PostgreSQL

Building a CRUD API with Atmo, Rust and PostgreSQL

I started my Wasm journey this summer with Golang and a browser and then with Node.js, tweeting every baby step of my progress. And that's how I got Connor Hicks' attention; This was the moment where he advised me to look at Atmo. Hmmm πŸ€” Ok, this is Rust, it won't be so easy: so far, my experience in Rust is limited to a simple "Hello World". But challenge accepted. πŸ– So don't judge my writing style in Rust.

Surprisingly, my first contact with Atmo was easy, and my first conclusion was that thanks to the Runnable SDK, it has never been easier to develop Wasm with Rust.

My first "RESTful API" in Rust

I'm French, so every time I go to Paris with English, American or German, co-workers, they ask me to handle dinner. I made a list for that https://gitlab.com/-/snippets/1835049, so why not use it to do my baby steps with Atmo?

I proudly present to you my first (very static) Atmo service:

use suborbital::runnable::*;
use serde_json::json;

struct Restaurants{}

impl Runnable for Restaurants {
    fn run(&self, _: Vec<u8>) -> Result<Vec<u8>, RunErr> {

        let restaurants = json!([
            {"name": "Le Ciel", "address": "17 rue Alexandre Dumas, 75011 Paris", "phone": "(33)664 441 416"},
            {"name": "A La Renaissance", "address": "87 rue de la Roquette, 75011 Paris", "phone": "(33)143 798 309"},
            {"name": "La Cave de l'Insolite", "address": "30 rue de la Folie MΓ©ricourt, 75011 Paris", "phone": "(33)153 360 833"}
        ]);

        suborbital::resp::content_type("application/json; charset=utf-8");

        Ok(restaurants.to_string().as_bytes().to_vec())
    }
}

The latest version of Atmo brings PostgreSQL support, among other things, so it's an opportunity to build something a little more serious.

Preparing the environment

With this blog post (or tutorial), what I want to achieve, is to create a Restaurants CRUD API with Atmo by using PostgreSQL. I'll use Docker Compose to set up my development environment. At the root of your project, create a docker-compose.yml file with this content:

services:
  postgres:
    image: postgres:latest
    environment:
      - POSTGRES_USER=root
      - POSTGRES_PASSWORD=password
      - APP_DB_USER=docker
      - APP_DB_PASS=docker
      - APP_DB_NAME=docker
    volumes:
      - ./db:/docker-entrypoint-initdb.d/
    ports:
      - 5432:5432
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U docker"]
      interval: 5s
      timeout: 5s
      retries: 5

  atmo-server:
    image: suborbital/atmo:latest
    container_name: atmo-server
    entrypoint: atmo
    environment:
      - ATMO_HTTP_PORT=8080
    ports:
      - 8080:8080
    depends_on:
      postgres:
        condition: service_healthy
    volumes:
      - ./restaurants:/home/atmo

We'll run two services: postgres to store our data and atmo-server that will load and start the Runnables. We need to write a script to create and populate the database. So, create a db directory, add it a 01-init.sh file with the below content:

#!/bin/bash
set -e
export PGPASSWORD=$POSTGRES_PASSWORD;
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
  CREATE USER $APP_DB_USER WITH PASSWORD '$APP_DB_PASS';
  CREATE DATABASE $APP_DB_NAME;
  GRANT ALL PRIVILEGES ON DATABASE $APP_DB_NAME TO $APP_DB_USER;
  \connect $APP_DB_NAME $APP_DB_USER
  BEGIN;
  CREATE TABLE IF NOT EXISTS restaurants (
    id SERIAL PRIMARY KEY,
    name varchar(250) NOT NULL,
    address varchar(250) NOT NULL,
    phone varchar(250) NOT NULL
  );
  INSERT INTO restaurants(name, address, phone) VALUES ('Le Ciel', '17 rue Alexandre Dumas, 75011 Paris', '(33)664 441 416');

  INSERT INTO restaurants(name, address, phone) VALUES ('A La Renaissance', '87 rue de la Roquette, 75011 Paris', '(33)143 798 309');

  INSERT INTO restaurants(name, address, phone) VALUES ('La Cave de lβ€˜Insolite', '30 rue de la Folie MΓ©ricourt, 75011 Paris', '(33)153 360 833');

  SELECT * FROM restaurants;

  COMMIT;
EOSQL

Now, we are ready to code!

The READ API

We will write the code to read the restaurants' list in the database and provide a JSON payload.

As prerequisites, you need the Subo CLI, it's easy to install:

brew tap suborbital/subo
brew install subo

Generate the "Restaurants" project and code the first service

With subo the Suborbital CLI, we'll generate the working project and our first Runnable:

subo create project restaurants
cd restaurants
rm -rf helloworld # we don't need it
subo create runnable readall

You should get this project structure:

β”œβ”€β”€ restaurants
   β”œβ”€β”€ Directive.yaml
   β”œβ”€β”€ Dockerfile
   β”œβ”€β”€ readall
   β”‚  └── src
   β”‚     └── lib.rs

Defining the database connection and the query

Edit Directive.yaml like below to create a database connection, a SQL query, and a route to call our new function:

identifier: com.suborbital.restaurants
appVersion: v0.1.0
atmoVersion: v0.4.0

connections:
  database:
    type: postgresql
    connectionString: postgresql://docker:docker@postgres:5432/docker

queries:
  - name: "SelectAllRestaurants"
    query: |-
      SELECT * FROM restaurants

handlers:
  - type: request
    resource: /restaurants
    method: GET
    steps:
      - fn: readall

Executing the query

Edit readall/src/lib.rs and update the dependencies and the run function like below:

use suborbital::runnable::*;
use suborbital::db;

struct Readall{}

impl Runnable for Readall {
    fn run(&self, _: Vec<u8>) -> Result<Vec<u8>, RunErr> {
        suborbital::resp::content_type("application/json; charset=utf-8");

        match db::select("SelectAllRestaurants", Vec::new()) {
            Ok(result) => Ok(result),
            Err(e) => {
                Err(RunErr::new(500, e.message.as_str()))
            }
        }
    }
}

the db::select("SelectAllRestaurants", Vec::new()) command will execute the "SelectAllRestaurants" query defined in Directive.yaml

Time to build and run the "readall" service

To build the bundle with the runnable, type the below commands:

cd restaurants
subo build .

This will generate a zip file (runnables.wasm.zip):

β”œβ”€β”€ restaurants
   β”œβ”€β”€ Directive.yaml
   β”œβ”€β”€ Dockerfile
   β”œβ”€β”€ runnables.wasm.zip πŸ–
   β”œβ”€β”€ readall
   β”‚  └── src
   β”‚     └── lib.rs

Then to create the database and serve the bundle, at the root of the project, run this command: docker-compose up and wait a few seconds. Once the database created, populate and started, you should get in your terminal an output similar to this one:

postgres_1     | 2021-11-23 05:34:04.302 UTC [1] LOG:  database system is ready to accept connections
atmo-server    | {"log_message":"(I) loaded bundle from ./runnables.wasm.zip","timestamp":"2021-11-23T05:34:09.902641098Z","level":3,"app":{"atmo_version":"0.4.2"}}
atmo-server    | {"log_message":"(I) starting Atmo ...","timestamp":"2021-11-23T05:34:09.912903046Z","level":3,"app":{"atmo_version":"0.4.2"}}
atmo-server    | {"log_message":"(I) serving on :8080","timestamp":"2021-11-23T05:34:09.912945469Z","level":3,"app":{"atmo_version":"0.4.2"}}

Executing the "readall" service

Remark: I use to run HTTPie CLI instead of curl command

To call our READ API, just type:

http http://localhost:8080/restaurants

And if everything is ok, you should get this:

[
    {
        "address": "17 rue Alexandre Dumas, 75011 Paris",
        "id": 1,
        "name": "Le Ciel",
        "phone": "(33)664 441 416"
    },
    {
        "address": "87 rue de la Roquette, 75011 Paris",
        "id": 2,
        "name": "A La Renaissance",
        "phone": "(33)143 798 309"
    },
    {
        "address": "30 rue de la Folie MΓ©ricourt, 75011 Paris",
        "id": 3,
        "name": "La Cave de lβ€˜Insolite",
        "phone": "(33)153 360 833"
    }
]

Easy! πŸ™‚

Add a new "READ API": get a restaurant by id

Before adding CREATE, UPDATE, DELETE, we'll create a readone service to get a restaurant record by its id:

cd restaurants
subo create runnable readone

Then add a new query "SelectOneRestaurant" with a parameter to Directive.yaml

queries:
  - name: "SelectAllRestaurants"
    query: |-
      SELECT * FROM restaurants
  - name: "SelectOneRestaurant"
    query: |-
      SELECT * FROM restaurants 
      WHERE id = $1

And a new route to call the readone service:

handlers:
  - type: request
    resource: /restaurants
    method: GET
    steps:
      - fn: readall
  - type: request
    resource: /restaurants/:key
    method: GET
    steps:
      - fn: readone

Now, edit readone/src/lib.rs and update the dependencies and the run function like below:

use suborbital::runnable::*;
use suborbital::db;
use suborbital::req;
use suborbital::db::query;

struct Readone{}

impl Runnable for Readone {
    fn run(&self, _: Vec<u8>) -> Result<Vec<u8>, RunErr> {
        suborbital::resp::content_type("application/json; charset=utf-8");

        let key = req::url_param("key");

        let mut query_args: Vec<query::QueryArg> = Vec::new();
        query_args.push(query::QueryArg::new("id", key.as_str()));

        match db::select("SelectOneRestaurant", query_args) {
            Ok(result) => Ok(result),
            Err(e) => {
                Err(RunErr::new(500, e.message.as_str()))
            }
        }
    }
}

Some explanations:

  • let key = req::url_param("key"); allow to get the id when we call http http://localhost:8080/restaurants/<id>
  • To pass the id as a paramater of the SQL query we use a vector of QueryArg: query_args.push(query::QueryArg::new("id", key.as_str()));

Then, build again with subo build . and serve with docker-compose up and finally query the readone service with http http://localhost:8080/restaurants/2 (to get the second restaurant) and you'll get:

[
    {
        "address": "87 rue de la Roquette, 75011 Paris",
        "id": 2,
        "name": "A La Renaissance",
        "phone": "(33)143 798 309"
    }
]

So, nothing really complicated and you'll learn how to use queries with parameters.

The CREATE, UPDATE, DELETE API

The remaining services are not more complicated, so I give you the source code and a few necessary explanations.

Create a restaurant

Create the runnable:

cd restaurants
subo create runnable create

The query:

- name: "AddRestaurant"
  query: |-
    INSERT INTO restaurants (name, address, phone) 
    VALUES ($1, $2, $3)

The route:

- type: request
  resource: /restaurants
  method: POST # πŸ– don't forget to change the method
  steps:
    - fn: create

The Rust source code of the Runnable:

This time:

  • We use the serde library to deserialize an instance of type Restaurant from a JSON string: let restaurant: Restaurant = serde_json::from_str(&in_string).unwrap();
  • We use the db::insert method to execute the query
use suborbital::runnable::*;
use suborbital::db;
use suborbital::db::query;

use serde::{Serialize, Deserialize};

#[derive(Serialize, Deserialize, Debug)]
struct Restaurant {
    name: String,
    address: String,
    phone: String
}

struct Create{}

impl Runnable for Create {
    fn run(&self, input: Vec<u8>) -> Result<Vec<u8>, RunErr> {
        suborbital::resp::content_type("application/json; charset=utf-8");

        let in_string = String::from_utf8(input).unwrap();
        // read the JSON message
        let restaurant: Restaurant = serde_json::from_str(&in_string).unwrap();
        // add parameters
        let mut query_args: Vec<query::QueryArg> = Vec::new();
        query_args.push(query::QueryArg::new("name", restaurant.name.as_str()));
        query_args.push(query::QueryArg::new("address", restaurant.address.as_str()));
        query_args.push(query::QueryArg::new("phone", restaurant.phone.as_str()));

        match db::insert("AddRestaurant", query_args) {
            Ok(result) => Ok(result),
            Err(e) => {
                Err(RunErr::new(500, e.message.as_str()))
            }
        }
    }
}

πŸ– don't forget to add the serde dependency to Cargo.toml:

[dependencies]
suborbital = '0.13.0'
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"

Use the create service like that:

http POST http://localhost:8080/restaurants \
name="Au Nouveau Nez" \
address="104 Rue Saint-Maur, 75011 Paris" \
phone="(33)143 550 230"

And check the creation with:

http http://localhost:8080/restaurants

Update a restaurant

Create the runnable:

cd restaurants
subo create runnable update

The query:

- name: "UpdateRestaurant"
  query: |-
    UPDATE restaurants
    SET name = $1, address = $2, phone = $3 
    WHERE id = $4

The route:

- type: request
  resource: /restaurants
  method: PUT # πŸ– don't forget to change the method
  steps:
    - fn: update

The Rust source code of the Runnable:

This time:

  • We use the db::update method to execute the query
  • We deserialize an instance of type Restaurant: let restaurant: Restaurant = serde_json::from_str(&in_string).unwrap();
  • We get the id of the restaurant to be updated: let key = req::url_param("key");
use suborbital::runnable::*;
use suborbital::req;
use suborbital::db;
use suborbital::db::query;

use serde::{Serialize, Deserialize};

#[derive(Serialize, Deserialize, Debug)]
struct Restaurant {
    name: String,
    address: String,
    phone: String
}

struct Update{}

impl Runnable for Update {
    fn run(&self, input: Vec<u8>) -> Result<Vec<u8>, RunErr> {
        suborbital::resp::content_type("application/json; charset=utf-8");

        let in_string = String::from_utf8(input).unwrap();
        let restaurant: Restaurant = serde_json::from_str(&in_string).unwrap();

        let key = req::url_param("key");

        let mut query_args: Vec<query::QueryArg> = Vec::new();

        query_args.push(query::QueryArg::new("name", restaurant.name.as_str()));
        query_args.push(query::QueryArg::new("address", restaurant.address.as_str()));
        query_args.push(query::QueryArg::new("phone", restaurant.phone.as_str()));
        query_args.push(query::QueryArg::new("id", key.as_str()));

        match db::update("UpdateRestaurant", query_args) {
            Ok(result) => Ok(result),
            Err(e) => {
                Err(RunErr::new(500, e.message.as_str()))
            }
        }
    }
}

Use the update service like that:

http PUT http://localhost:8080/restaurants/4 \
name="Au Nouveau Nez 🍷" \
address="104 Rue Saint-Maur, 75011 Paris" \
phone="(33)143 550 230"

πŸ– 4 is the id value of the restaurant

And check the update with:

http http://localhost:8080/restaurants/4

You should get:

[
    {
        "address": "104 Rue Saint-Maur, 75011 Paris",
        "id": 4,
        "name": "Au Nouveau Nez 🍷",
        "phone": "(33)143 550 230"
    }
]

Delete a restaurant

And finally to complete the CRUD API, the delete service πŸŽ‰

Create the runnable:

cd restaurants
subo create runnable delete

The query:

- name: "DeleteRestaurant"
  query: |-
    DELETE FROM restaurants
    WHERE id = $1

The route:

- type: request
  resource: /restaurants
  method: DELETE # πŸ– don't forget to change the method
  steps:
    - fn: delete

The Rust source code of the Runnable:

use suborbital::runnable::*;
use suborbital::req;
use suborbital::db;
use suborbital::db::query;

struct Delete{}

impl Runnable for Delete {
    fn run(&self, _: Vec<u8>) -> Result<Vec<u8>, RunErr> {
        suborbital::resp::content_type("application/json; charset=utf-8");

        let key = req::url_param("key");

        let mut query_args: Vec<query::QueryArg> = Vec::new();
        query_args.push(query::QueryArg::new("id", key.as_str()));

        match db::delete("DeleteRestaurant", query_args) {
            Ok(result) => Ok(result),
            Err(e) => {
                Err(RunErr::new(500, e.message.as_str()))
            }
        }
    }
}

Use the delete service like that:

http DELETE http://localhost:8080/restaurants/4

So, that's it for this time. You can find all the source code in this project: https://gitlab.com/k33g_org/discovering-atmo/atmo-postgresql, and if you open it with Gitpod, you don't need to install anything to start experimenting with Atmo.

Cover photo by Kari Shea on Unsplash