Axum+SeaORM+Async-graphql: Building a GraphQL Service from Scratch
Tags: rust, graphql
Published: 2024-03-21 02:04:58


This article takes a learner's perspective and guides you step by step to quickly set up a GraphQL service.

First, let's understand what Axum, SeaORM, and Async-graphql are.

**Axum**

Axum is a high-performance, asynchronous, modular framework for building web applications. It is based on the Rust language and relies on the Tokio asynchronous runtime for handling concurrency and I/O. The main features of Axum include:

- Ergonomics and modularity:

  - The design focuses on the developer experience, providing an easy-to-use API to organize the structure of applications.
  - Supports breaking down application logic into reusable modules, which helps build large and complex services.

- No macro-based routing:

  - Provides a mechanism for defining and matching HTTP routes without relying on macros, allowing you to clearly declare how requests are dispatched to corresponding handlers.

- Extractors:

  - Offers a declarative way to extract data from requests, such as query parameters, path parameters, form data, etc.

- Middleware support:

  - Allows the writing of custom middleware through native methods provided by the `axum::middleware` module or by combining existing middleware for authentication, logging, error handling, etc.

- Asynchronous services:

  - Based on Rust's asynchronous programming model, Axum can efficiently utilize system resources, achieving non-blocking I/O and high concurrency performance.

- Feature-rich:
  - Supports handling various HTTP features, such as GET, POST requests, file uploads, WebSocket connections, and serving static resources.

Axum does not have a very good documentation; when I was learning, I mainly referred to the [axum - Rust (docs.rs)](https://docs.rs/axum/) library documentation.

**SeaORM**

SeaORM is an Object-Relational Mapping (ORM) framework written in Rust. It aims to simplify interactions with SQL databases, allowing developers to represent database tables through structured Rust types and provides a convenient, type-safe, and intuitive way to perform CRUD (Create, Read, Update, and Delete) operations.

The main features of SeaORM include:

- Multi-database support:

  - SeaORM supports various SQL databases, such as SQLite, PostgreSQL, MySQL, etc., allowing developers to easily switch database backends without changing the code.

- Entity definition:

  - Developers can define `Entity` structs, annotate them to correspond to tables in the database, and automatically map columns to the struct's fields.

- Query builder:

  - Provides a flexible and powerful query-building API, making it simple and type-safe to write complex SQL queries.

- Active Record pattern:

  - SeaORM follows the ActiveRecord pattern, meaning entities can directly manipulate the database like objects, for example, saving, updating, and deleting records.

- Asynchronous support:

  - For high-performance application scenarios, SeaORM can integrate with asynchronous runtimes and database drivers (such as SQLx) to perform asynchronous I/O operations.

- Type safety:

  - It emphasizes the advantages of Rust's type system, ensuring that many potential database access errors are caught at compile time.

- Migration tools:

  - SeaORM also includes database migration tools to help developers manage version control and changes to the database schema.

- Modular design:
  - It has a high degree of modularity and extensibility, allowing the selection of partial features based on project requirements.

For more information, see the [SeaORM official tutorial](https://sea-ql.org/introduction).

**Async-graphql**

Async-graphql is an asynchronous GraphQL server library for the Rust language, allowing developers to build high-performance and GraphQL-compliant APIs. Async-graphql provides a complete GraphQL service implementation, including schema definition, resolvers, executors, and subscription features, and is deeply integrated with Rust's asynchronous programming model.

The main features of Async-graphql include:

- Asynchronous support: Fully designed based on Rust's asynchronous programming model, leveraging Rust's async/await keywords and Futures to efficiently utilize system resources when handling high-concurrency requests, avoiding blocking, and reducing context-switching overhead.

- Type safety: Ensures the safety of GraphQL APIs through Rust's powerful type system, allowing developers to define GraphQL types and catch errors at compile time.

- Zero-cost abstractions: Follows Rust's core principles, ensuring ease of use while minimizing runtime overhead.

- Flexible schema definition: Provides an intuitive way to define GraphQL schemas, which can be easily mapped to Rust data structures (such as enums, structs) and automatically generate corresponding GraphQL types.

- Integration convenience: Can seamlessly integrate with various Rust ecosystem database ORMs (such as Diesel) and HTTP server frameworks (such as Actix-web) for building full-stack Rust applications.

- Performance optimization: Due to the characteristics of the Rust language and the library's design, Async-graphql can run GraphQL queries with low memory usage and high throughput.

For more information, see the [Async-graphql official tutorial](https://async-graphql.github.io/async-graphql/).

The above is just a simple copy and paste of some concepts; the real highlight of this article is about to begin. I believe you are eager to start practicing.

**Quick Start**

1. Integrate GraphQL

**Shuttle**

Shuttle is a cloud service platform that allows users to access and utilize server and database resources for free, aiming to provide convenience for beginners so they can quickly start learning and practicing without additional investment.

You can log in with GitHub to get three free projects.

Install Shuttle:

```bash
cargo install cargo-shuttle
```

Initialize:

```bash
cargo shuttle init
```

Then choose the Axum framework.

Install dependencies:

```bash
cargo add async-graphql async-graphql-axum
```

Then modify `main.rs`:

```rust
use async_graphql::{http::GraphiQLSource, *};
use async_graphql_axum::GraphQL;
use axum::{
    response::{Html, IntoResponse},
    routing::get,
    Router,
};

// Define an enum

#[derive(Debug, Enum, PartialEq, Eq, Clone, Copy)]

enum Role {
    Admin,
    User,
}

impl Default for Role {
    fn default() -> Self {
        Self::User
    }
}

// Define a complex object

#[derive(Debug, SimpleObject, Default)]
#[graphql(complex)]
struct User {
    role: Role,
    username: String,
    email: String,
    address: String,
    age: i32,
}

#[ComplexObject]
impl User {
    async fn users(&self) -> String {
        format!("{:?}", self)
    }
}

// Define a query object

struct Query;

// Implement methods for the query object

#[Object]
impl Query {
    // Asynchronously return a string
    async fn hello(&self) -> String {
        "Hello, world!".to_string()
    }

    // Asynchronously return the GraphiQL page
    async fn graphql() -> impl IntoResponse {
        Html(GraphiQLSource::build().endpoint("/graphql").finish())
    }

    // Asynchronously return a static string
    async fn hello_world() -> &'static str {
        "Hello, world!"
    }
}

// Main function

#[shuttle_runtime::main]
async fn main() -> shuttle_axum::ShuttleAxum {
    // Build the GraphQL Schema
    let schema = Schema::build(Query, EmptyMutation, EmptySubscription)
        .finish();
    // Build the router
    let router = Router::new()
        .route("/", get(hello_world))
        .route("/graphql", get(graphql).post_service(GraphQL::new(schema)));

    // Use the router
    router
}

```

Then run:

```bash
cargo shuttle run
```

Enter `http://127.0.0.1:8000/graphql` in your browser to access the GraphQL playground.

2. Define database tables

**Set up migration**

If starting with a new database, it's best to version control the database schema. SeaORM comes with a migration tool that allows you to write migrations using SeaQuery or SQL, meaning you write migration code first and then generate entities, the specific content of which can be seen in the above documentation.

Install `sea-orm-cli`:

```bash
cargo install sea-orm-cli
```

Then initialize migration:

```bash
sea-orm-cli migrate init
```

Add the package to the workspace by modifying the `Cargo.toml` at the project root:

```toml
[dependencies]
// add
entries = { path = "./entries" }
// add
[workspace]
members = ["migration"]
```

Then write the migration file:

```rust
// Include the preset items from the sea_orm_migration library
use sea_orm_migration::prelude::*;

// Use the DeriveMigrationName trait to automatically generate migration names

#[derive(DeriveMigrationName)]
pub struct Migration;

// Implement the MigrationTrait trait, which defines the creation and deletion operations of the database table

#[async_trait]
impl MigrationTrait for Migration {
    // The up method is used to execute the upgrade operation of the database table structure, i.e., creating a new table or modifying the table structure
    async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
        // Create the User table
        manager.create_table(
            Table::create()
                .table(User::Table)
                // If the table does not exist, create it
                .if_not_exists()
                // Define theprimary key ID column as an integer type, not null, and auto-increment
                .col(ColumnDef::new(User::Id).integer().not_null().auto_increment().primary_key())
                // Username column, string type and not null
                .col(ColumnDef::new(User::Username).string().not_null())
                // Email column, string type and not null
                .col(ColumnDef::new(User::Email).string().not_null())
                // Address column, string type and not null
                .col(ColumnDef::new(User::Address).string().not_null())
                // Age column, integer type and not null
                .col(ColumnDef::new(User::Age).integer().not_null())
                // Convert the above definitions into an owned Table object
                .to_owned(),
        )
        .await?;
      ...
    }
}
```

3. Use Shuttle's database

**Shuttle Shared Databases**

Step 1: Install Shuttle Shared Database dependencies

To use Shuttle's shared database service in your project, first, you need to install the corresponding Rust libraries. Execute the following command in the directory where your `Cargo.toml` file is located:

```bash
cargo add shuttle-shared-db sea-orm sqlx \
    -F shuttle-shared-db/postgres \
    -F shuttle-shared-db/sqlx \
    -F sea-orm/sqlx-postgres \
    -F sea-orm/runtime-tokio-native-tls
```

This will add dependencies such as `shuttle-shared-db`, `sea-orm`, and `sqlx`, and specify related features.

Step 2: Configure the main program entry (src/main.rs)

Next, in the `src/main.rs` file, introduce the necessary modules and modify the main function to utilize Shuttle's database connection pool:

```rust
// Introduce necessary modules
use migration::MigratorTrait;
use sea_orm::SqlxPostgresConnector;
use shuttle_runtime::{main, ShuttleAxum};
use shuttle_shared_db::Postgres;

#[main]
async fn main(
    // Add Shuttle Shared Database's Postgres connection pool parameter
    #[shared_db(Postgres)] pool: sqlx::PgPool,
) -> ShuttleAxum {
    // Convert SQLx's PgPool to the connector required by SeaORM
    let connector = SqlxPostgresConnector::from_sqlx_postgres_pool(pool);
}
```

Step 3: Run the Shuttle application

Make sure Docker is installed, as the Shuttle framework will provide database services by starting database images locally.

Execute the following command to start your application:

```bash
cargo shuttle run
```

After Shuttle successfully starts, you will see the database connection address automatically built and assigned by Shuttle in the console. This address can be used directly to connect to the shared database instance managed by Shuttle, without manual configuration. Please refer to the log information output at runtime for specific connection details, as shown in the figure below.

Now, your application has integrated Shuttle's shared database and has performed the necessary initialization and connection settings, and you can continue to develop data operations and business logic.

4. Define entities

Step 1: Database migration

Before defining entities, first ensure that the database has been manually migrated. Use the following command to upgrade:

```bash
cargo run -p migration -- -u "postgres://postgres:postgres@localhost:22732/postgres"
```

If you need to revert to a previous migration version, execute the following downgrade command:

```bash
cargo run -p migration -- -u "postgres://postgres:postgres@localhost:22732/postgres" down
```

Step 2: Generate entities

After the database migration is successful, use the command-line tool (sea-orm-cli) provided by sea-orm to generate entity files:

```bash
sea-orm-cli generate entity \
    -o entity/src \
    -l \
    -u postgres://postgres:postgres@localhost:22732/postgres \
    --with-serde both \
    --model-extra-attributes='graphql(concrete(name = "Favorite", params()))' \
    --model-extra-derives async_graphql::SimpleObject
```

This will automatically generate entity model code based on the database table structure.

Step 3: Review the generated directory structure and entity code

The generated entity directory and its contents are as follows:

```
.
├── Cargo.toml
└── src
    ├── actions.rs
    ├── comment.rs
    ├── favorite.rs
    ├── lib.rs
    ├── prelude.rs
    └── user.rs
```

Take the `favorite.rs` file as an example; it contains the entity code automatically generated by sea-orm-codegen:

```rust
//! SeaORM Entity. Generated by sea-orm-codegen 0.12.14

// ... (omitted import part)

#[derive(...)]
#[sea_orm(table_name = "favorite")]

#[graphql(concrete(name = "Favorite" params()))] // There is a small error here
pub struct Model {
    #[sea_orm(primary_key, auto_increment = false)]
    pub user_id: i32,
    #[sea_orm(primary_key, auto_increment = false)]
    pub action_id: i32,
    pub create_time: Option,
}

// ... (omitted relationship and ActiveModelBehavior implementation part)
```

Step 4: Correct the generated entity code

Since there is an error in the GraphQL annotation in the generated `favorite.rs`, it needs to be manually modified:

```rust
#[graphql(concrete(name = "Favorite" params()))]
// Note that a comma is added here, and the name is correctly matched with the table name
```

Step 5: Initialize and configure the entity module

Enter the `entity` directory, initialize a new Rust project, and install the required dependencies:

```bash
cd entity
cargo init
cargo add async-graphql sea-orm serde -F serde/derive async-graphql/chrono
```

Step 6: Add the entity module to the workspace

Go back to the `Cargo.toml` file in the root directory, configure it as a workspace member, and add related dependencies:

```toml
[dependencies]
// Other existing dependencies...
migration = { path = "./migration" }
entity = { path = "./entity" }

[workspace]
members = ["entity", "migration"]
```

Now you have completed the definition and integration of entities. You can further refine or expand these entities and related service codes according to actual needs.

5. Write services

This section will guide you on how to create a new library package in a Rust application specifically for handling logic related to database interactions. We will implement user service operations through the sea-orm framework and prepare for subsequent writing of tests.

Step 1: Create a service library

First, create a new Rust library under the project root directory:

```bash
cargo new --lib service
```

Next, install the required dependencies:

```bash
cargo add entity --path ../entity
cargo add sea-orm -F debug-print,runtime-tokio-native-tls,sqlx-postgres
cargo add tokio --dev -F full
```

Step 2: Configure Cargo.toml file

Open the `service/Cargo.toml` file and add mock functionality, configuring related dependencies:

```toml
[package]
name = "service"
version = "0.1.0"
edition = "2021"

[dependencies]
entity = { version = "0.1.0", path = "../entity" }
sea-orm = { version = "0.12.14", features = [
    "debug-print",
    "runtime-tokio-native-tls",
    "sqlx-postgres",
] }

[dev-dependencies]
tokio = { version = "1.36.0", features = ["full"] }

[features]
mock = ["sea-orm/mock"]

[[test]]
name = "mock"
required-features = ["mock"]
```

Step 3: Create and configure test files

Create a file named `mock.rs` under the `tests/` directory, where we will later write mock tests for the service.

The current project directory structure should be as follows:

```
.
├── Cargo.toml
├── src
│   ├── actions.rs
│   ├── comment.rs
│   ├── favorite.rs
│   ├── lib.rs
│   ├── prelude.rs
│   └── user.rs
└── tests
    └── mock.rs
```

Step 4: Implement User service

In the `src/user.rs` file, we define the `UserServer` struct and implement methods for getting all users, getting a user by ID, creating a user, updating a user, and deleting a user:

```rust
use ::entity::{
    prelude::User,
    user::{ActiveModel, Model},
};
use sea_orm::*;

pub struct UserServer;

impl UserServer {
    /// Get all users
    pub async fn get_all_user(db: &DbConn) -> super::Result<()> {
        User::find().all(db).await
    }
}
```

Other service classes can be derived and implemented in a similar manner.

Summary:

So far, we have successfully created a new library package `service` and implemented user service operations. In the next section, we will write corresponding unit tests anduse the mock functionality provided by sea-orm to verify these services.

6. Write service tests

The directory structure is as follows:

```
.
├── Cargo.toml
├── src
│   ├── actions.rs
│   ├── comment.rs
│   ├── favorite.rs
│   ├── lib.rs
│   ├── prelude.rs
│   └── user.rs
└── tests
    ├── mock.rs
    └── prepare.rs
```

Step 1: Set up mock data in prepare.rs

First, in the `tests/prepare.rs` file, create a function to initialize and configure a mock database connection. This function will return a mock `DatabaseConnection` object and preset query results.

```rust
use ::entity::user;
use sea_orm::*;

#[cfg(feature = "mock")]
pub fn prepare_mock_db() -> DatabaseConnection {
    // Create a new Mock database connection
    MockDatabase::new(DatabaseBackend::Postgres)
        // Add query results
        .append_query_results(vec![
            // User 1
            [user::Model {
                id: 1,
                username: "张三".to_string(),
                email: "zhangsan@example.com".to_string(),
                address: "河南省郑州市".to_string(),
                age: 25,
            }],
            // User 2
            [user::Model {
                id: 2,
                username: "李四".to_string(),
                email: "lisi@example.com".to_string(),
                address: "广东省广州市".to_string(),
                age: 30,
            }],
            // User 3
            [user::Model {
                id: 3,
                username: "王五".to_string(),
                email: "wangwu@example.com".to_string(),
                address: "上海市".to_string(),
                age: 22,
            }],
            [user::Model {
                id: 4,
                username: "张三".to_string(),
                email: "zhangsan@example.com".to_string(),
                address: "河南省郑州市".to_string(),
                age: 25,
            }],
            [user::Model {
                id: 4,
                username: "李四".to_string(),
                email: "lisi@qq.com".to_string(),
                address: "地球村".to_string(),
                age: 0,
            }],
            [user::Model {
                id: 6,
                username: "张三6".to_string(),
                email: "zhangsan6@example.com".to_string(),
                address: "河南省郑州市".to_string(),
                age: 6,
            }],
        ])
        // Add execution results
        .append_exec_results([MockExecResult {
            last_insert_id: 4,
            rows_affected: 1,
        }])
        // Convert the Mock database connection to the DatabaseConnection type
        .into_connection()
}
```

Step 2: Write service tests in mock.rs

Next, in the `tests/mock.rs` file, write actual service tests. Use the `prepare_mock_db` function to obtain a mock database connection and perform various operations on the user service.

```rust
use entity::user::Model;
use service::user::UserServer;
mod prepare;

#[tokio::test]
async fn test_get_user_by_id() {
    // Prepare mock database connection
    let db = &prepare::prepare_mock_db();

    // ...
}
```

Run the test command:

```bash
cargo test -p service -F mock --test mock -- --nocapture
```

Note:

- Each database read operation requires a corresponding query result to be added in `prepare_mock_db`.
- Delete or other modification operations require corresponding exec_results to be added.
- After each operation, the Mock database automatically removes the matching buffered data.
- Therefore, when preparing mock results, be sure to consider how many read or delete operations are actually performed in the code.

7. Writing API Interface Tutorial

This section will guide you on how to create GraphQL API interfaces in a Rust application. We will implement user query and operation-related APIs based on the async-graphql and sea-orm frameworks.

Step 1: Create a Rust library

First, create a new Rust library under the project root directory for writing APIs:

```bash
cargo new --lib api
```

Step 2: Create file structure

Under the `api/src` directory, create `query` and `mutation` subdirectories, and establish corresponding modules and files within them. The final directory structure is as follows:

```
.
├── Cargo.toml
└── src
    ├── lib.rs
    ├── mutation
    │   ├── mod.rs
    │   └── user.rs
    └── query
        ├── mod.rs
        └── user.rs
```

Step 3: Configure Cargo.toml file

Edit the `api/Cargo.toml` file and add dependencies:

```toml
[package]
name = "api"
version = "0.1.0"
edition = "2021"

[dependencies]
service = { path = "../service" } // Introduce the service layer library
entity = { path = "../entity" } // Introduce the entity layer library
async-graphql = { version = "7.0.2", features = ["chrono"] }
sea-orm = { version = "0.12.14", features = [
    "sqlx-postgres",
    "runtime-tokio-native-tls",
]}
```

Step 4: Write query-related APIs

In the `query/user.rs` file, define the `UserQuery` struct and implement it as a GraphQL query object:

```rust
// query/user.rs
use async_graphql::*;
use service::user::UserServer;

#[derive(Default)]
pub struct UserQuery;

#[Object]
impl UserQuery {
    pub async fn get_users(&self, ctx: &Context<'_>) -> Result<()> {
        let db = ctx.data()?;
        let users = UserServer::get_all_user(db).await?;
        Ok(users)
    }
}

```

In the `query/mod.rs`, import and merge the query object:

```rust
// query/mod.rs
use async_graphql::MergedObject;

use self::user::UserQuery;
mod user;

#[derive(Default, MergedObject)]
pub struct Query(UserQuery);
```

Step 5: Write mutation-related APIs

In the `mutation/user.rs` file, define the `UserMutation` struct and implement it as a GraphQL mutation object, and also define input objects:

```rust
// mutation/user.rs
use async_graphql::*;
use entity::user;
use service::user::UserServer;

#[derive(InputObject)]
pub struct CreateUserInput {
    username: String,
    email: String,
    address: String,
    age: i32,
}

impl Into for CreateUserInput {
    fn into(self) -> user::Model {
        user::Model {
            id: 0,
            username: self.username,
            email: self.email,
            address: self.address,
            age: self.age,
        }
    }
}

#[derive(InputObject)]
pub struct UpdateUserInput {
    id: i32,
    email: String,
    address: String,
    age: i32,
}

impl Into for UpdateUserInput {
    fn into(self) -> user::Model {
        user::Model {
            id: self.id,
            username: "".to_string(),
            email: self.email,
            address: self.address,
            age: self.age,
        }
    }
}

#[derive(Default)]
pub struct UserMutation;

#[Object]
impl UserMutation {
    // Create a user
    pub async fn create_user(
        &self,
        ctx: &Context<'_>,
        input: CreateUserInput,
    ) -> Result<()> {
        let db = ctx.data()?;
        let res = UserServer::create_user(db, input.into()).await?;
        Ok(res)
    }

    // ...
}

// mutation/mod.rs
use async_graphql::MergedObject;

use self::user::UserMutation;
mod user;

#[derive(MergedObject, Default)]
pub struct Mutation(UserMutation);
```

Step 6: Build the GraphQL Schema

In the `src/lib.rs` file, import the query and mutation modules, and define a function to build the GraphQL schema:

```rust
// src/lib.rs
mod mutation;
mod query;
use async_graphql::*;
use mutation::Mutation;
use query::Query;
use sea_orm::DbConn;

pub fn build_schema(db: DbConn) -> Schema {
    Schema::build(Query::default(), Mutation::default(), EmptySubscription)
        .data(db)
        .finish()
}
```

Now you have successfully built user query and mutation-related GraphQL API interfaces based on async-graphql and sea-orm. In practice, you can integrate these interfaces into your application server to provide services as needed.

8. Deploy the application using Shuttle

Step 1: Integrate the API package in the main program

First, in the `src/main.rs` file under the project root directory, add the previously created `api` package as a dependency, and use the methods provided by the `api` to build the GraphQL schema in the main function.

```rust
// Introduce necessary modules and packages
use async_graphql::{http::GraphiQLSource, *};
use async_graphql_axum::GraphQL;
use axum::{
    response::{Html, IntoResponse},
    routing::get,
    Router,
};
use migration::MigratorTrait;
use sea_orm::SqlxPostgresConnector;
use api; // Introduce the api library

async fn graphql() -> impl IntoResponse {
    Html(GraphiQLSource::build().endpoint("/graphql").finish())
}

async fn hello_world() -> &'static str {
    "Hello, world!"
}

#[shuttle_runtime::main]
async fn main(#[shuttle_shared_db::Postgres] pool: sqlx::PgPool) -> shuttle_axum::ShuttleAxum {
    let coon = SqlxPostgresConnector::from_sqlx_postgres_pool(pool);
    migration::Migrator::up(&coon, None).await.unwrap();
    // ...
}

```

Step 2: Local run and test

Run the application locally to verify that the interfaces are working properly:

```bash
cargo shuttle run
```

Visit `http://localhost:3000/graphql` to view and test the GraphQL API interfaces, and check the welcome page at `http://localhost:3000/`. Upon success, you should see the GraphiQL page as shown below:

Step 3: Deploy to Shuttle

Idle Projects - Shuttle

Start a new project

Start a never-idle new project in the Shuttle environment (set `--idle-minutes` to 0):

```bash
cargo shuttle start --idle-minutes 0
```

Deploy the application

Use the `cargo shuttle deploy` command to deploy the application to the Shuttle platform:

```bash
cargo shuttle deploy
```

After successful deployment, you will see success messages similar to the following in the console:

Your application has now been successfully deployed to the Shuttle platform and can be accessed and managed as needed. Please note that actual deployment may require specific configurations based on the Shuttle environment.