叶师傅
Posted on March 21, 2024
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.
- Allows the writing of custom middleware through native methods provided by the
-
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) 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.
- Developers can define
-
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.
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.
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
- 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:
cargo install cargo-shuttle
Initialize:
cargo shuttle init
Then choose the Axum framework.
Install dependencies:
cargo add async-graphql async-graphql-axum
Then modify main.rs
:
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:
cargo shuttle run
Enter http://127.0.0.1:8000/graphql
in your browser to access the GraphQL playground.
- 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
:
cargo install sea-orm-cli
Then initialize migration:
sea-orm-cli migrate init
Add the package to the workspace by modifying the Cargo.toml
at the project root:
[dependencies]
// add
entries = { path = "./entries" }
// add
[workspace]
members = ["migration"]
Then write the migration file:
// 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?;
...
}
}
- 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:
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:
// 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:
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.
- Define entities
Step 1: Database migration
Before defining entities, first ensure that the database has been manually migrated. Use the following command to upgrade:
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:
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:
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:
//! 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:
#[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:
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:
[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.
- 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:
cargo new --lib service
Next, install the required dependencies:
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:
[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:
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.
- 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.
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.
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:
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.
- 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:
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:
[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:
// 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:
// 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:
// 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:
// 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.
- 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.
// 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:
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):
cargo shuttle start --idle-minutes 0
Deploy the application
Use the cargo shuttle deploy
command to deploy the application to the Shuttle platform:
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.
Posted on March 21, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
October 24, 2022
July 29, 2022