π 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
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
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:
- entity: where our database models are defined
- migration: where are database migrations are defined
To get started, we will create a barebones Rust project:
cargo new axum-graphql-seaorm
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
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
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
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;
This will allow us pull in those dependencies in our core crate or migration crate like:
use entity::{sea_orm, async_graphql};
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 {}
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!
}
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))
}
}
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;
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" }
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
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;
}
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()
}
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(())
}
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(())
}
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
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
}
}
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
Now that the files are created we will:
- create our
Query
type - create out
Mutation
type - 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())?)
}
}
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);
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
}
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!()
}
}
}
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);
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;
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()
}
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();
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();
}
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
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
}
}
getNotes
query {
getNotes {
id
title
text
}
}
getNoteById
query {
getNoteById(id:1) {
title
text
}
}
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 (3)
Looks like my example was moved here
based
github.com/SeaQL/sea-orm/tree/mast...
example not found