DEV Community

Aaron Leopold
Aaron Leopold

Posted on

Starter Axum, GraphQL and SeaORM Template

👋 Hello! I recently contributed to SeaORM and added a starter template I created for bootstrapping APIs using Axum, GraphQL and SeaORM. I wanted to share this here to:

  • give SeaORM some more exposure (it's a great ORM!)
  • help out some people wanting to try this stack
  • get some feedback to help me grow

In this post I'll give an overview of how to create the template from scratch, however the final template is available here if you'd like to just skip ahead past the "how I made this" part and take a look.

Project Structure 🧬

.
├── Cargo.lock
├── Cargo.toml
├── db
├── entity
│   ├── Cargo.toml
│   └── src
│       ├── lib.rs
│       └── note.rs
├── migration
│   ├── Cargo.lock
│   ├── Cargo.toml
│   ├── README.md
│   └── src
│       ├── lib.rs
│       ├── m20220101_000001_create_table.rs
│       └── main.rs
└── src
    ├── db.rs
    ├── graphql
    │   ├── mod.rs
    │   ├── mutation
    │   │   ├── mod.rs
    │   │   └── note.rs
    │   ├── query
    │   │   ├── mod.rs
    │   │   └── note.rs
    │   └── schema.rs
    └── main.rs

8 directories, 20 file
Enter fullscreen mode Exit fullscreen mode

Prerequisites 🥸

Before we get started, be sure to have rust/cargo properly installed on your machine. Then, we'll install some additional dependencies:

cargo install cargo-watch sea-orm-cli
Enter fullscreen mode Exit fullscreen mode

Note: sea-orm-cli might change soon.

I use cargo-watch on all my rust web projects so that the server restarts/recompiles on file changes. sea-orm-cli is a CLI utility that will help us create our migrations, although there are more functionalities available.

Setup 🔨

As shown in the Project Structure section, this template will have 2 additional crates:

  1. entity: where our database models are defined
  2. migration: where are database migrations are defined

To get started, we will create a barebones Rust project:

cargo new axum-graphql-seaorm
Enter fullscreen mode Exit fullscreen mode

This is just the standard hello world starter template. Now, we want to create the migration and entity crates. We'll start with the migrations.

cd into the project folder and run the following command:

sea-orm-cli migrate init
Enter fullscreen mode Exit fullscreen mode

This will create the migrations directory, with the boilerplate migration structure. To see more information about this, take a look at the corresponding section in the docs.

Now let's create the entity crate:

cargo new --lib entity
Enter fullscreen mode Exit fullscreen mode

We'll want to remove the boilerplate from this and then add the relevant dependencies. Add the following to your Cargo.toml:

[dependencies]
serde = { version = "1", features = ["derive"] }

[dependencies.async-graphql]
version = "3.0.12"

[dependencies.sea-orm]
version = "^0.6.0"
features = [
  "macros",
  "debug-print",
  "runtime-tokio-native-tls",
  "sqlx-sqlite",
]
default-features = false
Enter fullscreen mode Exit fullscreen mode

Notice I am using the sqlx-sqlite feature, for this template I am using sqlite. I typically start with sqlite early on in projects for ease of development, and then transition to MySQL/Postgres later on.

As per SeaORM's suggestion of structuring projects, we will share the sea-orm and async-graphql dependencies to the other crates as to prevent accidental mixing of versions. Add the following to lib.rs:

pub use async_graphql;
pub use sea_orm;
Enter fullscreen mode Exit fullscreen mode

This will allow us pull in those dependencies in our core crate or migration crate like:

use entity::{sea_orm, async_graphql};
Enter fullscreen mode Exit fullscreen mode

Create an entity

The relevant documentation for this section lives here.

Let's create our first entity. The demo API will interact with a simple collection of notes. The note entity will contain title and text fields.

Create a note.rs file with the following content:

use async_graphql::*;
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Serialize, Deserialize, SimpleObject)]
#[sea_orm(table_name = "notes")]
#[graphql(concrete(name = "Note", params()))]
pub struct Model {
    #[sea_orm(primary_key)]
    #[serde(skip_deserializing)]
    pub id: i32,
    pub title: String,
    pub text: String,
}

#[derive(Copy, Clone, Debug, EnumIter)]
pub enum Relation {}

impl RelationTrait for Relation {
    fn def(&self) -> RelationDef {
        panic!("No RelationDef")
    }
}

impl ActiveModelBehavior for ActiveModel {}
Enter fullscreen mode Exit fullscreen mode

This will define our Model and Relation for the note entity. Notice the derives throughout the file, but specifically the DeriveEntityModel and SimpleObject.

DeriveEntityModel defines an Entity with associating Model, Column and PrimaryKey.

SimpleObject will directly map the fields of the Model struct to a GraphQL object. You can even skip certain fields using #[graphql(skip)], like if you had a user entity with a password hash, for example.

Our note entity now has the following graphql type definition:

type Note {
  id: Int!
  title: String!
  text: String!
}
Enter fullscreen mode Exit fullscreen mode

Something I like to do is define some utility functions to my entities that don't come default with the Entity struct. Add the following the the bottom of your note.rs file:

note.rs

impl Entity {
    pub fn find_by_id(id: i32) -> Select<Entity> {
        Self::find().filter(Column::Id.eq(id))
    }

    pub fn find_by_title(title: &str) -> Select<Entity> {
        Self::find().filter(Column::Title.eq(title))
    }

    pub fn delete_by_id(id: i32) -> DeleteMany<Entity> {
        Self::delete_many().filter(Column::Id.eq(id))
    }
}
Enter fullscreen mode Exit fullscreen mode

And be sure to add the DeleteMany trait to the sea_orm use statement at the top. What this does is define some extra functions my note::Entity has. I do this mainly to remove code duplication and increase readability.

Add our note entity to our lib.rs file and let's create our first migration.

lib.rs

pub mod note;
Enter fullscreen mode Exit fullscreen mode

Defining our initial migration

We need to add a couple of dependencies to our migration crate. Add the following dependencies to the Cargo.toml file:

dotenv = "0.15.0"
entity = { path = "../entity" }
Enter fullscreen mode Exit fullscreen mode

While we could just hardcode the database URI, I like this little addition of dotenv in my templates to reduce repetition when starting new projects. Create an .env file at the project root, not the migration crate root, with the following line:

DATABASE_URL=sqlite:./db?mode=rwc
Enter fullscreen mode Exit fullscreen mode

The flags at the end are important, as seaorm needs to be to read and write our database file. Now, update your main.rs file in the migration crate to contain the following:

use migration::Migrator;
use sea_schema::migration::*;

#[cfg(debug_assertions)]
use dotenv::dotenv;

#[async_std::main]
async fn main() {
    #[cfg(debug_assertions)]
    dotenv().ok();

    let fallback = "sqlite:./db?mode=rwc";

    match std::env::var("DATABASE_URL") {
        Ok(val) => {
            println!("Using DATABASE_URL: {}", val);
        }
        Err(_) => {
            std::env::set_var("DATABASE_URL", fallback);
            println!("Set DATABASE_URL: {}", fallback);
        }
    };

    cli::run_cli(Migrator).await;
}
Enter fullscreen mode Exit fullscreen mode

All this addition really does is set the env variable if it is not present.

Now that we have that inital set up completed, we can write the actual code for our up and down migrations in the migration file (it will have the migration stamp postfixed with something like create_table.rs). Add the two following utility functions:

fn get_seaorm_create_stmt<E: EntityTrait>(e: E) -> TableCreateStatement {
    let schema = Schema::new(DbBackend::Sqlite);

    schema
        .create_table_from_entity(e)
        .if_not_exists()
        .to_owned()
}

fn get_seaorm_drop_stmt<E: EntityTrait>(e: E) -> TableDropStatement {
    Table::drop().table(e).if_exists().to_owned()
}
Enter fullscreen mode Exit fullscreen mode

I created these to reduce some repetition in my migration files. We are putting them in this migration file, however you can extract them out to a separate file if desired.

Our up migration will have the following content:

    async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
        let stmts = vec![get_seaorm_create_stmt(note::Entity)];

        for stmt in stmts {
            manager.create_table(stmt.to_owned()).await?;
        }

        Ok(())
    }
Enter fullscreen mode Exit fullscreen mode

I generate a statement vector and loop through it to create each table. I do the same for the down migration:

    async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
        let stmts = vec![get_seaorm_drop_stmt(note::Entity)];

        for stmt in stmts {
            manager.drop_table(stmt.to_owned()).await?;
        }

        Ok(())
    }
Enter fullscreen mode Exit fullscreen mode

With our up and down migrations created, we can now run a migration to create our table in our database. At the project root, run the following:

sea-orm-cli migrate up
Enter fullscreen mode Exit fullscreen mode

Inspecting the database you should see the notes table!

At this point, we've

  • created a basic structure for our server
  • defined a simple note entity
  • created an initial migration for the database

Next let's create a small Database struct to wrap SeaORM's DatabaseConnection, and then define our GraphQL Schema

Database Struct

I like to wrap SeaORM's DatabaseConnection in my own Database struct. I'll paste it below:

src/db.rs

use entity::sea_orm;
use sea_orm::DatabaseConnection;

pub struct Database {
    pub connection: DatabaseConnection,
}

impl Database {
    pub async fn new() -> Self {
        let connection = sea_orm::Database::connect(std::env::var("DATABASE_URL").unwrap())
            .await
            .expect("Could not connect to database");

        Database { connection }
    }

    pub fn get_connection(&self) -> &DatabaseConnection {
        &self.connection
    }
}
Enter fullscreen mode Exit fullscreen mode

I could just share the DatabaseConnection using OnceCell or directly injecting into the GraphQL context, however I prefer this method in case I want to add any additional functionality to my struct.

Creating the Schema

How you organize your project is entirely up to you, the structure that I have been using is to define a graphql module, that internally has query and mutation submodules, and a schema module. Let's create the files we will be editing, there will be a lot so I will go a little faster here:

mkdir src/graphql
touch src/graphql/mod.rs
touch src/graphql/schema.rs

mkdir src/graphql/query
touch src/graphql/query/mod.rs
touch src/graphql/query/note.rs

mkdir src/graphql/mutation
touch src/graphql/mutation/mod.rs
touch src/graphql/mutation/note.rs
Enter fullscreen mode Exit fullscreen mode

Now that the files are created we will:

  1. create our Query type
  2. create out Mutation type
  3. Define our schema

Create the Query type

Our Query type will have two queries: get all the notes, and get a note by an id. Here you can find more information about SeaORM's select query syntax.

src/graphql/query/note.rs

use async_graphql::{Context, Object, Result};
use entity::{async_graphql, note, sea_orm::EntityTrait};

use crate::db::Database;

#[derive(Default)]
pub struct NoteQuery;

#[Object]
impl NoteQuery {
    async fn get_notes(&self, ctx: &Context<'_>) -> Result<Vec<note::Model>> {
        let db = ctx.data::<Database>().unwrap();

        Ok(note::Entity::find()
            .all(db.get_connection())
            .await
            .map_err(|e| e.to_string())?)
    }

    async fn get_note_by_id(&self, ctx: &Context<'_>, id: i32) -> Result<Option<note::Model>> {
        let db = ctx.data::<Database>().unwrap();

        Ok(note::Entity::find_by_id(id)
            .one(db.get_connection())
            .await
            .map_err(|e| e.to_string())?)
    }
}
Enter fullscreen mode Exit fullscreen mode

We need to use #[Object] to declare our struct as a GraphQL Object. I am using a minimal error handling strategy here, as I didn't want to introduce too many dependencies in this template.

src/graphql/query/mod.rs

use entity::async_graphql;

pub mod note;

pub use note::NoteQuery;

// Add your other ones here to create a unified Query object
// e.x. Query(NoteQuery, OtherQuery, OtherOtherQuery)
#[derive(async_graphql::MergedObject, Default)]
pub struct Query(NoteQuery);
Enter fullscreen mode Exit fullscreen mode

To create a unified Query GraphQL type, I am using #[derive(async_graphql::MergedObject, Default)]. If you had another entity user and created a Query object for it called UserQuery, you would simply just need to add it as shown in the comment examples.

This is the resulting Query type:

type Query {
  getNotes: [Note!]!
  getNoteById(id: Int!): Note
}
Enter fullscreen mode Exit fullscreen mode

Create the Mutation type

Creating the Mutation type will be mostly the same:

src/graphql/mutation/note.rs

use async_graphql::{Context, Object, Result};
use entity::async_graphql::{self, InputObject, SimpleObject};
use entity::note;
use entity::sea_orm::{ActiveModelTrait, Set};

use crate::db::Database;

// I normally separate the input types into separate files/modules, but this is just
// a quick example.

#[derive(InputObject)]
pub struct CreateNoteInput {
    pub title: String,
    pub text: String,
}

#[derive(SimpleObject)]
pub struct DeleteResult {
    pub success: bool,
    pub rows_affected: u64,
}

#[derive(Default)]
pub struct NoteMutation;

#[Object]
impl NoteMutation {
    pub async fn create_note(
        &self,
        ctx: &Context<'_>,
        input: CreateNoteInput,
    ) -> Result<note::Model> {
        let db = ctx.data::<Database>().unwrap();

        let note = note::ActiveModel {
            title: Set(input.title),
            text: Set(input.text),
            ..Default::default()
        };

        Ok(note.insert(db.get_connection()).await?)
    }

    pub async fn delete_note(&self, ctx: &Context<'_>, id: i32) -> Result<DeleteResult> {
        let db = ctx.data::<Database>().unwrap();

        let res = note::Entity::delete_by_id(id)
            .exec(db.get_connection())
            .await?;

        if res.rows_affected <= 1 {
            Ok(DeleteResult {
                success: true,
                rows_affected: res.rows_affected,
            })
        } else {
            unimplemented!()
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

I am only adding a mutation to create and delete a note here, go ahead and try to create an update!

src/graphql/mutation/mod.rs

use entity::async_graphql;

pub mod note;

pub use note::NoteMutation;

// Add your other ones here to create a unified Mutation object
// e.x. Mutation(NoteMutation, OtherMutation, OtherOtherMutation)
#[derive(async_graphql::MergedObject, Default)]
pub struct Mutation(NoteMutation);
Enter fullscreen mode Exit fullscreen mode

Create the Schema

Now that we have defined our Mutation and Query types, let's go ahead and define our schema. To start, add the following to your mod.rs

src/graphql/mod.rs

pub mod mutation;
pub mod query;
pub mod schema;
Enter fullscreen mode Exit fullscreen mode

We won't actually be globally defining our schema in the schema.rs file, instead we will create a helper function that will build and return the schema. To do so, let's create a type for our schema and the function itself:

src/graphql/schema.rs

use async_graphql::{EmptySubscription, Schema};
use entity::async_graphql;

use crate::{
    db::Database,
    graphql::{mutation::Mutation, query::Query},
};

pub type AppSchema = Schema<Query, Mutation, EmptySubscription>;

/// Builds the GraphQL Schema, attaching the Database to the context
pub async fn build_schema() -> AppSchema {
    let db = Database::new().await;

    Schema::build(Query::default(), Mutation::default(), EmptySubscription)
        .data(db)
        .finish()
}
Enter fullscreen mode Exit fullscreen mode

We define the type of our schema according to our Query and Mutation types, and an empty subscription (since we don't have any subscriptions). If you had subscriptions, you would create it similar to how we created the others!

We create a database struct that the schema will manage using the data function call. This will inject the database struct into the GraphQL context. Looking back at either the query or mutation structs, you can see this in action:

let db = ctx.data::<Database>().unwrap();
Enter fullscreen mode Exit fullscreen mode

At this point:

  • We have our entity defined
  • We have the connection available, through Database struct
  • We defined our migration (and ran it!)
  • We created our Query/Mutation types, and our Schema

That pretty much wraps up everything we need to get started! All that is left is to create the server and test!

Create the Axum server

I chose Axum mostly to learn it, I have also used Rocket in the past. Both are great, choose whichever is your preference. The syntax of Rocket is probably a little better for me personally, however.

This has gotten long, so I'll paste the code below and overview it afterwards 🙂

src/main.rs

mod db;
mod graphql;

use entity::async_graphql;

use async_graphql::http::{playground_source, GraphQLPlaygroundConfig};
use async_graphql_axum::{GraphQLRequest, GraphQLResponse};
use axum::{
    extract::Extension,
    response::{Html, IntoResponse},
    routing::get,
    Router,
};
use graphql::schema::{build_schema, AppSchema};

#[cfg(debug_assertions)]
use dotenv::dotenv;

async fn graphql_handler(schema: Extension<AppSchema>, req: GraphQLRequest) -> GraphQLResponse {
    schema.execute(req.into_inner()).await.into()
}

async fn graphql_playground() -> impl IntoResponse {
    Html(playground_source(GraphQLPlaygroundConfig::new(
        "/api/graphql",
    )))
}

#[tokio::main]
async fn main() {
    #[cfg(debug_assertions)]
    dotenv().ok();

    let schema = build_schema().await;

    let app = Router::new()
        .route(
            "/api/graphql",
            get(graphql_playground).post(graphql_handler),
        )
        .layer(Extension(schema));

    println!("Playground: http://localhost:3000/api/graphql");

    axum::Server::bind(&"0.0.0.0:3000".parse().unwrap())
        .serve(app.into_make_service())
        .await
        .unwrap();
}
Enter fullscreen mode Exit fullscreen mode

graphql_handler actually handles the GraphQL requests, invoking the execute function of the schema using the request.

graphql_playground returns the HTML for the GraphQL Playground, this is the fancy looking version, as opposed to the GraphiQL version.

Notice we call the dotenv().ok() here, so the downstream uses, namely in db.rs can use the .env variables. We create the schema using the build_schema utility we built, and then add it as an extension layer to our axum app. I use the /api/graphql prefix for the get and post requests, as my brain prefers the semantics of pinging that instead of /graphql.

Wrapping it all up 🌯

Our application should be all good to run now! Double check you've imported all the required traits and what not, and give it a whirl! If you plan on making changes live and wanted a hot reload, run:

cargo watch -x run
Enter fullscreen mode Exit fullscreen mode

Otherwise just cargo run

Navigate to localhost:3000/api/graphql to get to the playground and test out the API!

createNote

mutation {
  createNote(input:{title:"Noice noice note", text:"This is the note content!"}) {
    id
  }
}
Enter fullscreen mode Exit fullscreen mode

getNotes

query {
  getNotes {
    id
    title
    text
  }
}
Enter fullscreen mode Exit fullscreen mode

getNoteById

query {
  getNoteById(id:1) {
    title
    text
  }
}
Enter fullscreen mode Exit fullscreen mode

Tada! We're done! 🎉

Thanks for bearing with me! Let me know if this was helpful or if you have any notes (I love notes!)

Additionally, if you're looking to dip your toes into a project that uses some of this technology stack, consider contributing to my personal project Stump - A free and open source comic book server with OPDS support, written in Rust and React.

Top comments (2)

Collapse
 
aaronleopold profile image
Aaron Leopold

Looks like my example was moved here

Collapse
 
bendo01 profile image
Benny Leonard Enrico Panggabean